// http://code.google.com/p/touchminder
//
// Run TouchMinder() on a scheduled trigger as a Google Apps Script.
// You'll need to add an API Key for your Tasks Service integration (Easy: https://developers.google.com/apps-script/service_tasks, https://developers.google.com/console/help/)
//
// Creates tasks for you to contact people you haven't talked to for a while.
// Adds a 'Touch Frequency (Days)' field to contacts - make a schedule per Contact
// New emails to/from a contact with make Task go away automatically
// "Complete" the task manually to signify a touch over a different channel (Phone, Chat, In-person)
//
// Known issues:
// Sporadic execution errors "Data Storage Error", "Backend Error" - not sure why.  But try it again and it works.
function TouchMinder() {
  Logger.log('Starting TouchUp at ' + (new Date()).toLocaleString());
  Logger.log('');
  
  var db = ScriptDb.getMyDb(); // Get the db
  
  // Who am I?
  var currentUserEmailAddress = Session.getActiveUser().getEmail();
  var myId = ContactsApp.getContact(currentUserEmailAddress).getId();
  var allCurrentUserEmailAddresses = ContactsApp
    .getContact(currentUserEmailAddress)
    .getEmails()
    .map(function (ea) { return ea.getAddress().trim().toLowerCase(); });
  
  // Cursor to [] of contactTouches ({ table: 'ContactTouches', ownerContactId: myId, contactId: contactId, lastTouch: number, taskId: taskId }) // Cannot store Date, so store milliseconds instead
  // ownerContactId because I believe the scriptDb is shared by the domain in Google Apps accounts.  If not, no real harm, right?.
  var contactTouches = [];   
  var queryResults = db.query({ table: 'ContactTouches', ownerContactId: myId });
  while(queryResults.hasNext()) {
    var result = queryResults.next();
    result.lastTouch = parseInt(result.lastTouch || '0'); // Parse the non-scientific notation version of the string
    contactTouches.push(result);
  }
  
  Logger.log('currentUserEmailAddresses: ' + allCurrentUserEmailAddresses.join(', '));
  
  // load all contacts
  var trackedContacts = loadContacts(contactTouches, myId);
  
  var maxDays = Math.max.apply( Math, trackedContacts.map(function (c) { return c.frequency; }) ) || 0; // What's the furthest back we should search?
  var earliestDate = new Date(); earliestDate.setDate(earliestDate.getDate() - maxDays);
  
  Logger.log('Found ' + trackedContacts.length + ' trackedContacts (' + contactTouches.length + ' known)');
  Logger.log('');
  
  var tasklistId = getTouchMinderTaskList();
  var processedTasks = processCompletedTasksGetRemaining(tasklistId, contactTouches, myId); // { completed: number, remaining: taskId[] }
  
  Logger.log('Touched ' + processedTasks.completed + ' contacts from completed tasks.');
  Logger.log('');
  
  Logger.log('Searching back as far as ' + earliestDate.toLocaleString());
  Logger.log('');
  
  var messages = new IterateMessages('in:anywhere -in:drafts'); // Iterate through ALL emails, sent & received.
  
  while (messages.moveNext()) {
    try {
      var message = messages.current.message;
      var thread = messages.current.thread;
      if (thread.getLastMessageDate() < earliestDate) break;
      
      var sender = extractRawEmailAddresses(message.getFrom())[0];
      var recipients = extractRawEmailAddresses(message.getTo() + ',' + message.getCc());
      var date = message.getDate().getTime(); // number!
      
      if (allCurrentUserEmailAddresses.filter(function (myEmail) { return sender === myEmail; }).length !== 0) { // Sent email        
        for (var r in recipients) {
          var recipient = recipients[r];
          
          var matchingContacts = trackedContacts.filter(function (tc) { // find matching contacts where the lastTouch is older than this date AND there's an email match
            var notMyself = tc.contactId !== myId;
            var interestedAtAll = 0 < tc.frequency;
            var updated = tc.contactTouch.lastTouch < date;
            var matched = 0 < tc.allEmails.filter(function (ea) { return ea === recipient }).length;
            
            var combined = 
                notMyself && // Not myself
                interestedAtAll && // This contact even matters
                updated && // and this is an update
                matched; // and there is a match
          
            return combined; 
          });
          
          for (var mc in matchingContacts) // update the lastTouch
          {
            Logger.log('Found a recent email to ' + matchingContacts[mc].contact.getFullName());
            matchingContacts[mc].contactTouch.lastTouch = date;
          }
        }
      } else { // Received email
        var matchingContacts = trackedContacts.filter(function (tc) {  // find matching contacts where the lastTouch is older than this date AND there's an email match
            var notMyself = tc.contactId !== myId;
            var interestedAtAll = 0 < tc.frequency;
            var updated = tc.contactTouch.lastTouch < date;
            var matched = 0 < tc.allEmails.filter(function (ea) { return ea === sender }).length;
            
            var combined = 
                notMyself && // Not myself
                interestedAtAll && // This contact even matters
                updated && // and this is an update
                matched; // and there is a match
          
            return combined; 
          });
        
        for (var mc in matchingContacts) // update the lastTouch
        {
          Logger.log('Found a recent email from ' + matchingContacts[mc].contact.getFullName());
          matchingContacts[mc].contactTouch.lastTouch = date;
        }
      }
    } catch (ex) {
        Logger.log('Error ' + ex + ' ' + message.getSubject());
        throw ex;
    }
  }
    
  Logger.log('Finished scanning');
  
  // For which tasks to remove
  var allRecentlyEmailed = trackedContacts.filter(function (tc) { 
    var myself = tc.contactId === myId;
    var notInterested = tc.frequency === 0;
    var notExpired = tc.cutoffDate < tc.contactTouch.lastTouch;
    var taskExpected = tc.contactTouch.taskId != null;    
    
    var combined = (myself || // myself
      notInterested || // We don't want to keep up with this contact
      notExpired) &&  // lastTouch is after the cutoff for this contact
      taskExpected; // and the taskId is not null;
    
    return combined; // As far as I could tell, didn't act the same when not combined
  });
  
  // For which tasks to add
  var allNewlyExpired = trackedContacts.filter(function (tc) {
    var notMyself = tc.contactId !== myId;
    var interestedAtAll = 0 < tc.frequency;
    var expired = tc.contactTouch.lastTouch <= tc.cutoffDate;
    var noTask = tc.contactTouch.taskId == null;
    var taskMissing = processedTasks.remaining.filter(function (r) { return r === tc.contactTouch.taskId; }).length === 0;
    
    var combined = 
      notMyself && // Not myself
      interestedAtAll && // We do want to keep up with this contact
      expired && // lastTouch is before the cutoff for this contact
      (noTask || // and there is no taskId
        taskMissing); // or the task is missing
    
    //Logger.log('Add?: ' + notMyself.toString() + ' ' + interestedAtAll.toString() + ' ' + expired.toString() + ' ' + noTask.toString() + ' ' + taskMissing.toString() + ' => ' + combined.toString());
    return combined; // As far as I could tell, didn't act the same when not combined
  });
  
  for (var re in allRecentlyEmailed)
  {
    // delete the task
    var recentlyEmailed = allRecentlyEmailed[re];    
    
    var taskId = processedTasks.remaining.filter(function (r) { return r === recentlyEmailed.contactTouch.taskId; })[0];
    
    try
    {
      if (taskId)
      {
        Tasks.Tasks.remove(tasklistId, taskId);
      }
      
      recentlyEmailed.contactTouch.taskId = null;
    }
    catch(e)
    {
      Logger.log('!! Unable to remove task ' + taskId);
    }
  }
  
  Logger.log('Deleted ' + allRecentlyEmailed.length + ' tasks');
  
  for (var ne in allNewlyExpired)
  {
    // add a task
    var newlyExpired = allNewlyExpired[ne];
    var title = 'Check in on ' + newlyExpired.contact.getFullName() + ' <' + newlyExpired.primaryEmail + '>';
    Logger.log('Adding task "' + title + '"');
    var newTask = Tasks.newTask().setTitle(title).setDue(Utilities.formatDate(new Date(), "GMT", "yyyy-MM-dd'T'HH:mm:ss.SSS'Z'"));
    newlyExpired.contactTouch.taskId = Tasks.Tasks.insert(newTask, tasklistId).getId();
  }
  
  Logger.log('Added ' + allNewlyExpired.length + ' tasks');
  
  var remainingContactTouches = [];
  
  for (var ct in contactTouches)
  {
    var contactTouch = contactTouches[ct];
    contactTouch.lastTouch = contactTouch.lastTouch.toString(); // Convert back to a non-scientific-notation string
    
    if (typeof contactTouch.stillAContact === 'undefined') // If the contact no longer exists, we should remove the contactTouch db entry
    {
      try
      {
        if (contactTouch.taskId)
        {
          Tasks.Tasks.remove(tasklistId, contactTouch.taskId); // Remove any outstanding Task too.
        }
        
        contactTouch.taskId = null;
      }
      catch(e)
      {
        Logger.log('!! Unable to remove task ' + contactTouch.taskId + ' because ' + e);
      }
      
      db.remove(contactTouch);
    }
    else
    {
      delete contactTouch.stillAContact; // We don't actually save this property, it is only used temporarily so delete it.
      remainingContactTouches.push(contactTouch);
    }
  }
  
  db.saveBatch(remainingContactTouches, false);
};

// From and To lines in email are each presented as a raw string - we need to extract just the email address for matching to Contact email addresses.
// This takes a string of comma or semi-colon delimited email addresses (including optionally quotes full-names preceeding brackets addresses, or just naked email addresses)
// and returns an array of normalized addressed.
function extractRawEmailAddresses(addressLine)
{
  var reEmail = /\s*(?:([^"<,;]+)|(?:(?:(?:(?:"(?:""|[^"<,;])*")|[^"<,;]*)?\s*<([^"<>,;]*)>)))(?:$|,|;)/gi;
  var extracted = [];
  var captures;
  while (captures = reEmail.exec(addressLine))
  {
    extracted.push((captures[2] || captures[1]).trim().toLowerCase());
  }
  
  return extracted;
}

// Ensure that the TaskList exists and return the ID.
function getTouchMinderTaskList()
{
  var title = "TouchMinders";
  var allLists = Tasks.Tasklists.list().getItems();
  var id;
  for (var i in allLists) {
    if (title == allLists[i].getTitle()) {
      return allLists[i].getId();
    }
  }
  
  var newTaskList = Tasks.newTaskList();
  newTaskList.setTitle(title);
  return Tasks.Tasklists.insert(newTaskList).getId();
}

// Look for manually Completed Tasks and interpret that to mean that the Contact was touched by the user through another channel (Phone, Chat, in-person)
function processCompletedTasksGetRemaining(tasklistId, contactTouches)
{
  var tasks = Tasks.Tasks.list(tasklistId).getItems();
  
  var completed = 0;
  var remaining = [];
  
  for (var t in tasks) {
    var task = tasks[t];
    var taskId = task.getId();
    var completion = task.getCompleted();
    if (completion)
    {
      var found = contactTouches.filter(function (ct) { return ct.taskId === taskId })[0];
      
      if (found) {
        remaining.push(taskId);
        
        var lastTouch = new Date(completion).getTime();
        
        if (found.lastTouch < lastTouch)
        {
          found.lastTouch = lastTouch;
        }
        completed++;
      }
      // else we weren't tracking that one anyway
    }
    else
    {
      remaining.push(taskId);
    }
  }
  
  return { completed: completed, remaining: remaining };
}

// Get all contacts from ContactsApp Google Service and pair them with cached data found in contactTouches ScriptDB
function loadContacts(contactTouches, myId)
{
  // My Contacts only - perhaps later this is configurable?
  return ContactsApp.getContactGroup("System Group: My Contacts").getContacts()
    .map(function (c) {
      var touchFrequencyFields = c.getCustomFields('Touch Frequency (Days)'); // Get the Contact's preferred Touch Frequency in Days as configured in the Google Contacts App custom field
      var touchFrequency = '30';
      
      if (touchFrequencyFields.length === 0) { // if there is no such setting, create one and set it to a default of 30 days
        c.addCustomField('Touch Frequency (Days)', touchFrequency);
      }
      else {
        touchFrequency = touchFrequencyFields[0].getValue();
      }
      
      var contactId = c.getId();
      var contactTouch = contactTouches.filter(function (ct) { return ct.contactId === contactId; })[0]; // Find the matching contactTouch
      
      if (!contactTouch) // if it doesn't exist, add it to the array (it will be saved to the ScriptDB at the end of the entire script execution)
      {
        contactTouch = { table: 'ContactTouches', ownerContactId: myId, contactId: contactId, lastTouch: 0, taskId: null };
        contactTouches.push(contactTouch);
      }
      
      contactTouch.stillAContact = {}; // just to define it.
      
      var frequency = parseInt(touchFrequency, 10) || 30; // Attempt to parse the touchFrequency string as an int or default to 30 days
      var cutoffDate = new Date(); cutoffDate.setDate(cutoffDate.getDate() - frequency); // Calculate the earliest date to search for touches with this contact
      
      return { 
        frequency: frequency, 
        cutoffDate: cutoffDate.getTime(),
        contact: c,
        contactId: contactId,
        primaryEmail: c.getPrimaryEmail(),
        allEmails: c.getEmails().map(function (ea) { return ea.getAddress().trim().toLowerCase(); }),
        contactTouch: contactTouch
      };
    });
}

/*
// IterateMessages created with the help of the Tracuer project from this source:
function* IterateMessages(searchCriteria, backwardFromDate) {
  var oldestYetObserved = (backwardFromDate || new Date()).getTime()/1000 | 0;
  
  while(true)
  {
    // Older seems non-inclusive, which is desirable for this purpose, otherwise we'd loop infinitely on any single result
    var batchSearchCriteria = searchCriteria + ' older:' + oldestYetObserved.toString(); 
    var threads = GmailApp.search(batchSearchCriteria, 0, 10);
    
    if (threads.length === 0) break;
    
    for (var i = 0; i < threads.length; i++) {
      var messages = threads[i].getMessages();
      for (var j = 0; j < messages.length; j++) {
        yield messages[j];
      }
    }
    
    oldestYetObserved = threads[threads.length - 1].getLastMessageDate().getTime()/1000 | 0;
  }
};

for (var message in IterateMessages('in:anywhere'))
{
  console.log(message);
}
*/

// Lazily iterates through email messages.  
// Optionally pass any search criteria other than 'older:' for searchCriteria ex: 'in:inbox from:(john.doe@internet.com) subject:(Lunch?) has:attachment'.
// Optionally pass a Date from which to iterate backwards in time (by each Thread's most recent email date)
// Refer to the commented ECMAScript Harmony version above for basic details
function IterateMessages(searchCriteria, backwardFromDate) {
  var $that = this;
  var $arguments = arguments;
  var $state = 20;
  var $storedException;
  var $finallyFallThrough;
  var batchSearchCriteria;
  var i;
  var j;
  var messages;
  var threadMessages;
  var oldestYetObserved;
  var threads;
  var $result = {moveNext: function($yieldSent) {
      while (true) try {
        switch ($state) {
          case 20:
            oldestYetObserved = (backwardFromDate || new Date()).getTime() / 1000 | 0;
            $state = 21;
            break;
          case 21:
            if (true) {
              $state = 10;
              break;
            } else {
              $state = 19;
              break;
            }
          case 10:
            batchSearchCriteria = (searchCriteria || '') + ' older:' + oldestYetObserved.toString();
            $state = 11;
            break;
          case 11:
            threads = GmailApp.search(batchSearchCriteria, 0, 50);
            messages = GmailApp.getMessagesForThreads(threads);
            $state = 13;
            break;
          case 13:
            if (messages.length === 0) {
              $state = 14;
              break;
            } else {
              $state = 15;
              break;
            }
          case 14:
            $state = 19;
            break;
          case 15:
            i = 0;
            $state = 7;
            break;
          case 7:
            if (i < messages.length) {
              $state = 5;
              break;
            } else {
              $state = 9;
              break;
            }
          case 4:
            i++;
            $state = 7;
            break;
          case 5:
            threadMessages = messages[i];
            $state = 6;
            break;
          case 6:
            j = 0;
            $state = 2;
            break;
          case 2:
            if (j < threadMessages.length) {
              $state = 0;
              break;
            } else {
              $state = 4;
              break;
            }
          case 1:
            j++;
            $state = 2;
            break;
          case 0:
            $result.current = { thread: threads[i], message: threadMessages[j] };
            $state = 1;
            return true;
          case 9:
            oldestYetObserved = threads[threads.length - 1].getLastMessageDate().getTime() / 1000 | 0;
            $state = 21;
            break;
          case 19:
            $state = 23;
          case 23:
            return false;
          case 22:
            throw $storedException;
          default:
            throw "invalid state in state machine " + $state;
        }
      } catch ($caughtException) {
        $storedException = $caughtException;
        switch ($state) {
          default:
            throw $storedException;
        }
      }
    }};
  return $result;
}

// List everything in the ScriptDB for debugging
function showAll() {
  var db = ScriptDb.getMyDb();
  var results = db.query({});

  while (results.hasNext()) {
    var result = results.next();
    Logger.log(Utilities.jsonStringify(result));
  }
}

// Quickly reset the taskId for all ContactTouches
function reset() {
  var currentUserEmailAddress = Session.getActiveUser().getEmail();
  var myId = ContactsApp.getContact(currentUserEmailAddress).getId();
  
  var contactTouches = [];  
  var db = ScriptDb.getMyDb();  
  var queryResults = db.query({ table: 'ContactTouches', ownerContactId: myId });
  
  while(queryResults.hasNext()) { var next = queryResults.next(); next.taskId = null; contactTouches.push(next); }
  
  db.saveBatch(contactTouches, false);
}

// Slowly delete the ScriptDB items one at a time (might affect all domain users!)
function deleteAll() {
   var db = ScriptDb.getMyDb();
   while (true) {
     var result = db.query({}); 
     if (result.getSize() == 0) {
       break;
     }
     while (result.hasNext()) {
       db.remove(result.next());
     }
   }
}

function Test(){
  
  // Who am I?
  var currentUserEmailAddress = Session.getActiveUser().getEmail();
  var me = ContactsApp.getContact(currentUserEmailAddress);
  
  var fields = me.getCustomFields();
  
  for (var f in fields)
  {
    Logger.log(fields[f].getLabel().toString() + ': ' + fields[f].getValue().toString());
  }
}